iT邦幫忙

2026 iThome 鐵人賽

DAY 14
0
Software Development

ERP 架構師筆記:定義驅動的框架設計系列 第 14

Day 14:業務物件的擴充點與覆寫時機

  • 分享至 

  • xImage
  •  

Day 14:業務物件的擴充點與覆寫時機

定義表達得了的行為,一行程式都不必寫。表達不了的那些才需要接手:這張單據送出前要檢查什麼、存完之後要通知誰、刪除前什麼情況不准刪。框架給的接手方式是繼承業務物件,覆寫其中幾個步驟。

子類別依需求在對應的步驟加進這張表單自己的邏輯,難的是判斷「對應的」是哪一步,以及那段程式該寫在 base 的前面還是後面。方法名稱只答得出順序,DoBeforeSave 在存檔之前、DoAfterSave 在存檔之後,其餘要由框架主動講明。

本篇說明:

  1. 什麼時候該繼承業務物件,什麼時候不必
  2. 一個 Save 為什麼要拆成好幾個步驟
  3. 六個步驟各自的使用場景
  4. 覆寫時,base 那一行前後的差異
  5. 一個方法要不要開放覆寫,判準是什麼

一、什麼時候該繼承業務物件

Day 3 那八張表單裡七張的應用程式碼是零行,只有訂單那一張指定了自己的型別。增修刪查與驗證都由定義長出來,會需要寫程式的是定義表達不了的那一段。

所以繼承之前有三層要先問過,順序由淺到深:

這件事能不能… 交付方式 誰改得動
寫成欄位上的運算式或一條規則(Day 8) 改一份定義檔 懂業務的人
寫成一個掛在時點上的 plugin(Day 21) 換一個組件 開發者
都不行,才繼承業務物件 換一個組件,改掉這張表單綁的型別 開發者

三層的差別不在難度,在交付路徑:愈上面的愈能跟著定義一起走,愈下面的愈接近「回到程式碼那一側」。下面那兩層都得出一個組件,而 plugin 只在固定的時點加一段,套裝原本那一段一定會跑;繼承手上有 base 那一行,套裝那一段留不留由子類決定。這條軸線是 Day 30 的骨幹,這裡只取一個推論:繼承是最後一層。

判別法沿用 Day 2 那條:這件事全世界的 ERP 做起來是不是都一樣?一樣的遲早會被收成機制,不一樣的才該由應用寫。訂單的狀態轉換規則每一家都不同,那是繼承的正當理由;至於總計等於明細加總,它現在寫在應用裡只是因為框架還沒收。

以套裝請假單為例,假別選特休,存檔之前就要驗他還剩幾天、夠不夠這次請,而那是假單自己的邏輯,所以套裝假單要繼承框架的業務物件基底下來改寫。客製再往下接一層:某一家客戶算特休的方式跟套裝不一樣,就繼承套裝這個假單的業務物件再改寫一次,把套裝那一段驗證換掉。同一個機制接了兩層,兩種理由。

繼承的入口是註冊表上那一列,指定 ProgId 對應到哪一個型別(Day 15 的題目)。宣告之後框架建出來的就是你的子類,覆寫的那幾個步驟會被叫到,沒覆寫的照原樣跑。


二、一個 Save 為什麼要拆成好幾個步驟

假設框架只給一支 Save 讓人覆寫。應用想在存檔前擋一筆資料,就得把整支重寫,而那一支裡有授權檢查、資料範圍檢查、規則求值、持久化、稽核寫入。要加三行,得先把這五件事原樣抄回來,抄漏一件就是一個安靜的漏洞。

拆開之後,覆寫的人只接手自己那一段。寫入面能動的是六個 protected virtual 步驟:

Save:   DoBeforeSave   →  DoSave   →  [變更稽核]  →  DoAfterSave
Delete: DoBeforeDelete →  DoDelete →  [刪除稽核]  →  DoAfterDelete
                            ↑
                      這兩個步驟在 transaction 內

Save 的骨架節錄在 FormBusinessObject.Write.cs 裡,順序一眼可讀:

DoBeforeSave(context);
plugins.RunBeforeSave(context);
// ...擷取變更集(改動前/改動後)供稽核使用
DoSave(context);                          // transaction 在這一句之內開啟並 commit
if (auditChange) WriteChangeAudit(...);
DoAfterSave(context);
plugins.RunAfterSave(context);

DoSaveDoDelete 為什麼各自是一條 transaction

transaction 由 Repository 在 DoSave 內部開與關,涵蓋的是整筆單據:主檔一句、明細有幾列就有幾句,全部成功才 commit,任何一句失敗就整筆退回。存進去的必須是一張完整的單,不能是主檔在而明細少了幾列。

刪除要的是同一件事的另一半:主檔刪掉了,底下的明細也必須跟著消失,否則留下的是一批對不到主檔的明細列。這兩種殘缺 Day 11 都講過,共同點是資料庫不會替你擋,因為框架不建外鍵約束。形狀也在那裡:存檔主檔先、明細後,刪除反過來。

所以存檔與刪除各有一步落在裡面:DoSaveDoDelete 是六段裡僅有的兩個帶原子性承諾的步驟,其餘四段都在外面。

稽核為什麼被切成兩半

擷取改動前後的值排在 DoSave 之前,因為持久化走的是 ADO.NET 的 DataAdapter,成功之後它會把每一列標成已接受,原值與列狀態一起被丟掉。寫入稽核那一列則在 DoSave 之後,要等資料真的存進去才有意義。

擷取點的另一邊也被釘住:它排在 DoBeforeSave 之後,計算欄與規則填上去的值才進得了稽核。一個位置被兩側同時釘住,通常就改不動了,而看程式碼的順序看不出它為什麼在那裡。

代價是「資料寫成功、稽核寫失敗」可能發生,框架接受了這個落差。前面那個決定不是這樣:整筆單據的語句要嘛一起算數、要嘛一起不算,框架不給部分成功。兩個決定相反,因為壞掉的方式不同。

壞掉的後果 決定
主檔與明細 留下一張明細不齊的單,而且不會報錯 強制同 transaction
變更稽核 少一列記錄,資料本身是對的 接受非原子

原子性不是越多越好,它有價格,要逐項決定付不付。稽核本身的形狀是 Day 25 的題目。


三、六個步驟各自的使用場景

步驟 適合放什麼
DoBeforeSave 填預設值、算跨列的合計、擋明顯錯誤的輸入
DoSave 必須與這筆資料同生共死的寫入
DoAfterSave 通知、呼叫外部系統、丟進佇列
DoBeforeDelete 依刪除前的整筆資料判斷准不准刪
DoDelete 要跟著一起刪掉的其他資料
DoAfterDelete 把刪除這件事傳給下游

要接手就覆寫這六個之一,不要覆寫 public 的 Save / Delete,否則就回到上一節開頭那個處境:授權、資料範圍、稽核那幾段全部得自己原樣抄回來。讀取那一側同樣有幾個開好的位置,最常用到的是取一筆新資料時填預設值,以及替開窗選取的候選列收窄,例如只列出還在啟用中的。

那全部包進 transaction 不就好了

以存檔那一側為例,各段依序要付的代價是:

若把這一段納入 代價
DoBeforeSave 規則求值、跨表關連的查詢、呼叫其他業務物件全部在鎖內。一張表單的規則越多,鎖就抓得越久
稽核寫入 每次存檔都多一列必須同時成功的寫入。軌跡那一側出問題,變成業務單據存不進去
DoAfterSave 外部系統的延遲直接變成鎖持有時間。對方逾時,等於這裡的連線被佔著不放

這在 ERP 上特別要緊。單據之間關連得密,一次存檔動到的常常不只自己那一張表,於是兩個看起來無關的功能會落在同一批列上:這邊的出貨單還沒放掉庫存那幾列,那邊的成本計算就得等。鎖持有的時間越長,這種互相卡住就越常發生。

所以拆成六段是把「必須原子」的範圍壓到最小,transaction 裡只有寫入。其餘每一段都可以慢,慢不會傷到別人。

DoAfterSave 丟例外時,資料已經寫進去了

DoAfterSave 丟例外,例外會往上拋、該次呼叫回報失敗,但這一段開始之前資料已經寫進去了。使用者看到「存檔失敗」,資料庫裡那筆單據卻在,而且是完整的。所以放在這裡的副作用要能被重試,或交給佇列而不是同步執行;同步送出一個通知然後失敗,就沒有任何東西可以重試(不同可靠性等級各該用哪一種,是 Day 21 的題目)。


四、base 那一行決定你看到什麼

自己的邏輯寫在 base 前面還是後面,看它需要什麼。那一行不是形式,它決定你手上這份資料處在什麼狀態,而六個步驟的 base 做的事各不相同:

步驟 base 做的事 呼叫之後多了什麼
DoBeforeSave 套用預設值與計算欄運算式,再跑 BeforeSave 驗證規則 算完的值。寫在它前面看到的是呼叫端送上來的原樣
DoBeforeDelete 拿刪除前的快照跑 BeforeDelete 規則 同上,而且它讀的是快照,不是送上來的資料
DoSave / DoDelete 整筆持久化 資料已經寫進去,transaction 已經 commit
DoAfterSave / DoAfterDelete 什麼都不做 沒有差別,前後等價

DoBeforeSave 是最常覆寫的位置,也最容易放錯邊。計算欄的值是 base 那一行填上去的,自訂檢查要看得到它就得排在後面;反過來,要在規則跑之前先塞一個值進去,那才是該寫在前面的東西。

同一個 context,不同位置拿得到的東西不一樣

三個步驟共用一個 SaveContext,它從頭傳到尾,省掉每一段各自去解析 Repository 與 FormSchema。但同一個物件在不同位置的內容不同:

SaveContext 的成員 DoBeforeSave DoAfterSave
Args / DataSet / Repository / Schema
RefreshedDataSet null 持久化後回寫的那一份
AffectedRows 每張表的異動列數

DataSet 兩邊都拿得到,可以做的事卻相反。在前面那個位置改它,改動會跟著寫進資料庫;在後面那個位置改它沒有任何作用,要影響呼叫端收到什麼得改 RefreshedDataSet。同一個屬性,一邊是輸入、一邊是紀錄。

刪除那一側另有一份快照,內容是刪除前的整筆資料(主檔加明細),供 BeforeDelete 規則比對、也供稽核記下改動前的內容。它有兩個性質:

  • 只在真的有人要用的時候才載入。稽核關著、這張表單沒有刪除前規則、也沒有掛任何 plugin,就完全不讀,直接刪除那條路徑因此是零查詢的。
  • 判斷「有沒有人要用」時把 plugin 算進去,而這一項非算不可:不算的話,快照在不在就取決於稽核那個開關,同一個 plugin 會在一個部署拿得到資料、換一個部署拿到 null

第二點是擴充點設計的通則:一個擴充點手上拿得到什麼,不該取決於另一個功能有沒有開。


五、一個方法要不要開放覆寫

反過來問:框架決定要不要開一個新位置的時候,是照什麼判的?

判準只有一條:有實際需求才開。現在沒開的那些,多數不是不能開,是還沒有人真的需要。理由在於它不可逆:擴充點是對外承諾,開放之後有人覆寫了就收不回來,而套裝軟體的維護期十年二十年起跳。

兩種「現在沒開」,性質不一樣

一種是設計上就不開。授權、資料範圍與稽核那幾支全部是 private,判別法是這一段的存在理由是不是「不管誰來都要跑」,是的話開放覆寫等於把它變成可選的。

另一種只是還沒有需求,數量遠比第一種多,隨時可以變成擴充點。把第二種說成「框架不支援」會讓人去繞路,而繞路留下的東西比一個晚一步開出來的擴充點難收拾得多。

需求來了之後,開在哪一層

開在最小的那一層。開窗選取的候選列,整段查詢都可以覆寫,但框架另外開了一個小位置,只讓你加一個條件把候選列收窄,因為多數人要的就是這個。

還有一種做法是把入口和實作分開:入口不開放覆寫,只負責呼叫另一支開放覆寫的實作方法,覆寫錯那一支根本編不過。SaveDelete 沒有這樣做,所以「不要覆寫這一支」只能靠文件說。

所以現在開著的每一個位置,背後都有一個當時真的存在的需求;沒開的那一批,等需求收斂出新的共通行為,就會再長出對應的位置。


回到 Northwind

案例的 OrderBO 覆寫了兩個步驟,寫入面的那個是 DoBeforeSave,裡面做四件事。逐件問「放在這個位置對不對」,答案不一樣:

這一件 要不要問資料庫 放在這裡
至少一列明細 可以,純記憶體判定
主檔總額等於明細加總 可以,純記憶體計算
狀態轉換是否合法 是(讀存量狀態) 有空窗
訂單編號配號 是(讀當月最大值) 有空窗

前兩件的輸入全部在手上那份 DataSet 裡,位置正確。後兩件都要回頭問資料庫,於是讀完到 DoSave 寫進去之間留了一段空窗,另一個人可能已經在那段時間裡把前提改掉。

訂單編號那一件的形狀最單純,程式在 OrderBO 裡:

var currentMax = Repository().GetMaxOrderNumber($"ORD-{yearMonth}-%");
master["sys_id"] = OrderRules.NextOrderNumber(yearMonth, currentMax);

查當月最大值加一,兩個人同時開單就會拿到同一個號碼。擋下它的不是這段程式,是資料庫:

CREATE UNIQUE INDEX "uk_ft_order" ON "ft_order" ("sys_id" ASC)

第二筆 INSERT 在 DoSave 內被拒絕,整筆 rollback。這個索引不是為了這件事特地加的,sys_id 是框架的系統欄位,由 FormSchema 產生 TableSchema 時就自動配上了。防線確實落在寫入的當下,只是它不是程式寫出來的,是定義宣告出來的;代價是撞號的那個使用者要重存一次。

狀態轉換那一件沒有索引可以擋:兩個人同時把同一張單從草稿確認掉,兩邊讀到的都是草稿,兩邊都判定合法,而分開看兩次寫入也都沒有錯,只是不該同時發生。要擋住得把「目前狀態必須還是草稿」寫進那句 UPDATE,案例沒有做到這一層。


小結

擴充點的位置決定它的語意,而位置不會寫在方法名稱上:

  • Before 說的是順序,不是保護。這裡讀到的值只在讀的那一刻成立。
  • DoSaveDoDelete 是與資料同生共死的那一段,而要把一條規則放進去,靠的多半不是覆寫它們,是讓資料庫自己在寫入當下判定。
  • After 之後,資料已經是別人看得到的事實了。

可以帶走的判準:框架開一個擴充點的時候,除了「什麼時候跑」,還欠使用者一句「它在 transaction 的哪一邊」。少了那一句,覆寫的人會照著方法名稱推論,而名稱推論不出邊界。

這句話對所有掛在流程上的機制都成立。哪一段在裡面、哪一段在外面決定的不只是效能,是那段程式能不能承諾它剛剛看到的東西還算數。

明天談這些擴充點掛在哪一個型別上:ProgId、合約分層與型別註冊表。


本系列同步發表於 HackMD,完整目錄


上一篇
Day 13:Session 管理與生命週期
下一篇
Day 15:API 合約分層與型別註冊表
系列文
ERP 架構師筆記:定義驅動的框架設計26
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言